--- jupytext: text_representation: extension: .md format_name: myst format_version: 0.13 jupytext_version: 1.10.3 kernelspec: display_name: Python 3 (ipykernel) language: python name: python3 --- +++ {"slideshow": {"slide_type": "slide"}} # Monte Carlo and Root Finding +++ {"slideshow": {"slide_type": "-"}, "tags": ["remove-cell"]} **CS1302 Introduction to Computer Programming** ___ +++ {"slideshow": {"slide_type": "subslide"}, "tags": []} ## The Monty-Hall Game ```{code-cell} ipython3 --- slideshow: slide_type: '-' tags: [hide-input] --- %%html ``` +++ {"slideshow": {"slide_type": "subslide"}} **Is it better to change the initial pick? What is the chance of winning if we change?** +++ {"slideshow": {"slide_type": "fragment"}} *Hint:* There are two doors to choose from, and only one of the doors has treasure behind. +++ {"slideshow": {"slide_type": "fragment"}} Let's use the following program to play the game a couple of times. ```{code-cell} ipython3 --- code_folding: [] slideshow: slide_type: '-' --- import random def play_monty_hall(num_of_doors=3): """Main function to run the Monty Hall game.""" doors = {str(i) for i in range(num_of_doors)} door_with_treasure = random.sample(doors, 1)[0] # Input initial pick of the door. while True: initial_pick = input(f'Pick a door from {", ".join(sorted(doors))}: ') if initial_pick in doors: break # Open all but one other door. Opened door must have nothing. doors_to_open = doors - {initial_pick, door_with_treasure} other_door = ( door_with_treasure if initial_pick != door_with_treasure else doors_to_open.pop() ) print("Door(s) with nothing behind:", *doors_to_open) # Allow player to change the initial pick the other (unopened) door. change_pick = ( input(f"Would you like to change your choice to {other_door}? [y/N] ").lower() == "y" ) # Check and record winning. if not change_pick: mh_stats["# no change"] += 1 if door_with_treasure == initial_pick: mh_stats["# win without changing"] += 1 return print("You won!") else: mh_stats["# change"] += 1 if door_with_treasure == other_door: mh_stats["# win by changing"] += 1 return print("You won!") print(f"You lost. The door with treasure is {door_with_treasure}.") mh_stats = dict.fromkeys( ("# win by changing", "# win without changing", "# change", "# no change"), 0 ) def monty_hall_statistics(): """Print the statistics of the Monty Hall games.""" print("-" * 30, "Statistics", "-" * 30) if mh_stats["# change"]: print( f"% win by changing: \ {mh_stats['# win by changing'] / mh_stats['# change']:.0%}" ) if mh_stats["# no change"]: print( f"% win without changing: \ {mh_stats['# win without changing']/mh_stats['# no change']:.0%}" ) ``` ```{code-cell} ipython3 --- slideshow: slide_type: subslide tags: [remove-output] --- play_monty_hall() monty_hall_statistics() ``` +++ {"slideshow": {"slide_type": "fragment"}} You may also [play the game online](https://math.ucsd.edu/~crypto/Monty/monty.html). +++ {"slideshow": {"slide_type": "subslide"}} To get a good estimate of the chance of winning, we need to play the game many times. We can write a Monty-Carlo simulation instead. ```{code-cell} ipython3 --- deletable: false editable: false nbgrader: cell_type: code checksum: e8cf784178caa46c7fbd46c61ba83e54 grade: false grade_id: monty-hall locked: true schema_version: 3 solution: false task: false slideshow: slide_type: '-' --- # Do not change any variables defined here, or some of the tests may fail. import numpy as np np.random.randint? np.random.seed(0) # for reproducible result num_of_games = int(10e7) door_with_treasure = np.random.randint(1, 4, num_of_games, dtype=np.uint8) initial_pick = np.random.randint(1, 4, num_of_games, dtype=np.uint8) print(f"{'Door with treasure:':>19}", *door_with_treasure[:10], "...") print(f"{'Initial pick:':>19}", *initial_pick[:10], "...") ``` +++ {"slideshow": {"slide_type": "fragment"}} - `door_with_treasure` stores as 8-bit unsigned integers `uint8` the door numbers randomly chosen from $\{1, 2, 3\}$ as the doors with treasure behind for a number `num_of_games` of Monty-Hall games. - `initial_pick` stores the initial choices for the different games. +++ {"slideshow": {"slide_type": "fragment"}} If players do not change their initial pick, the chance of winning can be estimated as follows: ```{code-cell} ipython3 :code_folding: [] def estimate_chance_of_winning_without_change(door_with_treasure, initial_pick): """Estimate the chance of winning the Monty Hall game without changing the initial pick using the Monte Carlo simulation of door_with_treasure and initial_pick.""" count_of_win = 0 for x, y in zip(door_with_treasure, initial_pick): if x == y: count_of_win += 1 return count_of_win / n n = num_of_games // 100 estimate_chance_of_winning_without_change(door_with_treasure[:n], initial_pick[:n]) ``` However, the above code is inefficient and takes a long time to run. You may try running it on the entire sequences of `door_with_treasure` and `initial_pick` but **DO NOT** put the code in your notebook, as the server refuses to autograde notebooks that take too much time or memory to run. +++ {"slideshow": {"slide_type": "fragment"}} A simpler and also more efficient solution with well over 100 times speed up is as follows: ```{code-cell} ipython3 --- slideshow: slide_type: '-' --- def estimate_chance_of_winning_without_change(door_with_treasure, initial_pick): """Estimate the chance of winning the Monty Hall game without changing the initial pick using the Monte Carlo simulation of door_with_treasure and initial_pick.""" return (door_with_treasure == initial_pick).mean() estimate_chance_of_winning_without_change(door_with_treasure, initial_pick) ``` +++ {"slideshow": {"slide_type": "fragment"}} The code uses the method `mean` of `ndarray` that computes the mean of the `numpy` array. In computing the mean, `True` and `False` are regarded as `1` and `0` respectively, as illustrated below. ```{code-cell} ipython3 --- slideshow: slide_type: '-' --- for i in True, False: for j in True, False: print(f"{i} + {j} == {i + j}") ``` +++ {"slideshow": {"slide_type": "subslide"}} **Exercise** Define the function `estimate_chance_of_winning_by_change` same as `estimate_chance_of_winning_without_change` above but returns the estimate of the chance of winning by changing the initial choice instead. Again, *implement efficiently or jupyterhub may refuse to autograde your entire notebook*. *Hint:* Since there are only two unopened doors at the end of each game, a player will win by changing the initial pick if the initially picked door is not the door with treasure behind. ```{code-cell} ipython3 --- deletable: false nbgrader: cell_type: code checksum: 6b9be14256e67db2acf4f2ae073a57ae grade: false grade_id: switch locked: false schema_version: 3 solution: true task: false slideshow: slide_type: '-' tags: [remove-output] --- def estimate_chance_of_winning_by_change(door_with_treasure, initial_pick): """Estimate the chance of winning the Monty Hall game by changing the initial pick using the Monte Carlo simulation of door_with_treasure and initial_pick.""" # YOUR CODE HERE raise NotImplementedError() ``` ```{code-cell} ipython3 --- deletable: false editable: false nbgrader: cell_type: code checksum: 29d0c3318c2c66dbf7c1792859a908ca grade: true grade_id: test-switch locked: true points: 1 schema_version: 3 solution: false task: false slideshow: slide_type: '-' tags: [hide-input, remove-output] --- # tests assert np.isclose( estimate_chance_of_winning_by_change(door_with_treasure[:10], initial_pick[:10]), 0.7, ) ``` ```{code-cell} ipython3 --- deletable: false editable: false nbgrader: cell_type: code checksum: 4d5596f079ce618f7b208bfaa5df26e5 grade: true grade_id: h_test-switch locked: true points: 1 schema_version: 3 solution: false task: false tags: [remove-cell] --- # hidden tests ``` +++ {"slideshow": {"slide_type": "subslide"}} ## Solving a 3-by-3 system of linear equations +++ {"slideshow": {"slide_type": "fragment"}} `numpy` has a module `linalg` for linear algebra, and the module provides a function called `solve` that can solve a system of linear equations. For the example in the lecture $$ \begin{aligned} 2 x_0 + 2 x_1 &= 1\\ 2 x_1 &= 1, \end{aligned} $$ we can obtain the solution as follows: ```{code-cell} ipython3 --- slideshow: slide_type: fragment --- np.linalg.solve? A = np.array([[2.0, 2], [0, 2]]) b = np.array([1.0, 1]) x = np.linalg.solve(A, b) ``` +++ {"slideshow": {"slide_type": "fragment"}} As explained in the lecture, the arguments `A` and `b` are obtained from the matrix form of the system of linear equations: $$ \underbrace{ \begin{bmatrix} 2 & 2\\ 0 & 2 \end{bmatrix}}_{\mathbf{A}} \underbrace{ \begin{bmatrix} x_0\\ x_1 \end{bmatrix}}_{\mathbf{x}} = \underbrace{ \begin{bmatrix} 1 \\ 1 \end{bmatrix} }_{\mathbf{b}} $$ +++ {"slideshow": {"slide_type": "fragment"}} However, the function returns an error when there is no unique solution. ```{code-cell} ipython3 --- slideshow: slide_type: '-' --- # Case with infinitely many solution A = np.array([[2.0, 2], [2, 2]]) b = np.array([1.0, 1]) x = np.linalg.solve(A, b) ``` ```{code-cell} ipython3 --- slideshow: slide_type: '-' --- # Case without solution A = np.array([[2.0, 2], [2, 2]]) b = np.array([1.0, 0]) x = np.linalg.solve(A, b) ``` +++ {"slideshow": {"slide_type": "fragment"}} A unique solution does not exist if and only if the [determinant](https://en.m.wikipedia.org/wiki/Determinant) of $\mathbf{A}$ is $0$, in which case $\mathbf{A}$ is called a singular matrix. For a $2$-by-$2$ matrix, the determinant is defined as follows: $$ \begin{aligned} \operatorname{det}(A) &:= \left| \begin{matrix} a_{00} & a_{01}\\ a_{10} & a_{11} \end{matrix} \right|\\ &= a_{00}\times a_{11} - a_{01}\times a_{10}. \end{aligned} $$ +++ {"slideshow": {"slide_type": "fragment"}} For example, the first system has a unique solution because $$ \left| \begin{matrix} 2 & 2\\ 0 & 2 \end{matrix} \right| = 2\times 2 - 2\times 0 = 4>0. $$ The last two systems do not have unique solutions because $$ \left| \begin{matrix} 2 & 2\\ 2 & 2 \end{matrix} \right| = 2\times 2 - 2\times 2 = 0. $$ We can use the function `det` from `np.linalg` to compute the determinant as follows: ```{code-cell} ipython3 np.linalg.det? np.linalg.det(np.array([[2.0, 2], [0, 2]])), np.linalg.det(np.array([[2.0, 2], [2, 2]])) ``` +++ {"slideshow": {"slide_type": "subslide"}} **Exercise** Use the `det` and `solve` functions to assign `x` to the `numpy` array storing the solution of the following linear system if the solution is unique else `None`. $$ \begin{aligned} x_0 + 2 x_1 + 3x_2 &= 14\\ 2x_0 + x_1 + 2x_2 &= 10\\ 3 x_0 + 2x_1 + x_2 &= 10. \end{aligned} $$ ```{code-cell} ipython3 --- deletable: false nbgrader: cell_type: code checksum: c08555513b5b41d8863530cc1d01888d grade: false grade_id: linalg locked: false schema_version: 3 solution: true task: false slideshow: slide_type: '-' tags: [remove-output] --- # YOUR CODE HERE raise NotImplementedError() x ``` ```{code-cell} ipython3 --- code_folding: [] deletable: false editable: false nbgrader: cell_type: code checksum: f560fce9950282f5284df26adf91772c grade: true grade_id: test-linalg locked: true points: 1 schema_version: 3 solution: false task: false slideshow: slide_type: '-' tags: [hide-input, remove-output] --- # tests # As the main test must be hidden, you may want to verify your solution # as explained in the lecture using matrix multiplication. assert isinstance(x, np.ndarray) and x.shape == (3,) ``` ```{code-cell} ipython3 --- deletable: false editable: false nbgrader: cell_type: code checksum: 824c6e9697bed8dce93e16c5fe9d852f grade: true grade_id: h_test-linalg locked: true points: 1 schema_version: 3 solution: false task: false tags: [remove-cell] --- # hidden tests ``` +++ {"slideshow": {"slide_type": "subslide"}} ## Solving non-linear equations +++ {"slideshow": {"slide_type": "fragment"}} Suppose we want to solve: $$ f(x) = 0 $$ for some possibly non-linear real-valued function $f(x)$ in one real-valued variable $x$. Quadratic equation with an $x^2$ term is an example. The following is another example. ```{code-cell} ipython3 --- slideshow: slide_type: '-' --- %matplotlib inline import matplotlib.pyplot as plt import numpy as np f = lambda x: x * (x - 1) * (x - 2) x = np.linspace(-0.5, 2.5) plt.plot(x, f(x)) plt.axhline(color="gray", linestyle=":") plt.xlabel(r"$x$") plt.title(r"Plot of $f(x)=x(x-1)(x-2)$") plt.show() ``` +++ {"slideshow": {"slide_type": "fragment"}} While it is clear that the above function has three roots, namely, $x=0, 1, 2$, can we write a program to compute a root of any given continuous function $f$? ```{code-cell} ipython3 --- slideshow: slide_type: subslide --- %%html ``` +++ {"slideshow": {"slide_type": "subslide"}} The following function `bisection` - takes as arguments - a continuous function `f`, - two real values `a` and `b`, - a positive integer `n` indicating the maximum depth of the recursion, and - returns a list `[xstart, xstop]` if the bisection succeeds in capturing a root in the interval `[xstart, xstop]` bounded by `a` and `b`, or else, returns a empty list `[]`. ```{code-cell} ipython3 --- code_folding: [13] slideshow: slide_type: '-' --- def bisection(f, a, b, n=10): if f(a) * f(b) > 0: return [] # because f(x) may not have a root between x=a and x=b elif n <= 0: # base case when recursion cannot go any deeper return [a, b] if a <= b else [b, a] else: c = (a + b) / 2 # bisect the interval between a and b return bisection(f, a, c, n - 1) or bisection(f, c, b, n - 1) # recursion # bisection solver import ipywidgets as widgets @widgets.interact(a=(-0.5, 2.5, 0.5), b=(-0.5, 2.5, 0.5), n=(0, 10, 1)) def bisection_solver(a=-0.5, b=0.5, n=0): x = np.linspace(-0.5, 2.5) plt.plot(x, f(x)) plt.axhline(color="gray", linestyle=":") plt.xlabel(r"$x$") plt.title(r"Bisection on $f(x)$") [xstart, xstop] = bisection(f, a, b, n) plt.plot([xstart, xstop], [0, 0], "r|-") print("Interval: ", [xstart, xstop]) ``` +++ {"slideshow": {"slide_type": "fragment"}} Try setting the values of $a$ and $b$ as follows and change $n$ to see the change of the interval step-by-step. ```{code-cell} ipython3 --- slideshow: slide_type: '-' --- bisection(f, -0.5, 0.5), bisection(f, 1.5, 0.5), bisection(f, -0.1, 2.5) ``` +++ {"slideshow": {"slide_type": "subslide"}} **Exercise** Modify the function `bisection` to - take the floating point parameter `tol` instead of `n`, and - return the interval from the bisection method represented by a list `[xstart,xstop]` but as soon as the gap `xstop - xstart` is $\leq$ `tol`. ```{code-cell} ipython3 --- deletable: false nbgrader: cell_type: code checksum: 6a4aae6b76256c6f757465fd4a93bed5 grade: false grade_id: bisection locked: false schema_version: 3 solution: true task: false slideshow: slide_type: '-' tags: [remove-output] --- def bisection(f, a, b, tol=1e-9): # YOUR CODE HERE raise NotImplementedError() ``` ```{code-cell} ipython3 --- deletable: false editable: false nbgrader: cell_type: code checksum: a47780044fab40faea56393c61f7dcd9 grade: true grade_id: test-bisection locked: true points: 1 schema_version: 3 solution: false task: false slideshow: slide_type: '-' tags: [hide-input, remove-output] --- # tests import numpy as np f = lambda x: x * (x - 1) * (x - 2) bisection(f, 1.5, 0.5) assert np.isclose(bisection(f, -0.5, 0.5), [-9.313225746154785e-10, 0.0]).all() _ = bisection(f, 1.5, 0.5, 1e-2) assert np.isclose(_, [1.0, 1.0078125]).all() or np.isclose(_, [0.9921875, 1.0]).all() assert np.isclose( bisection(f, -0.1, 2.5, 1e-3), [1.9998046875000002, 2.0004394531250003] ).all() ``` ```{code-cell} ipython3 --- deletable: false editable: false nbgrader: cell_type: code checksum: 8160b43badb41f359045b5d097afe543 grade: true grade_id: h_test-bisection locked: true points: 1 schema_version: 3 solution: false task: false tags: [remove-cell] --- # hidden tests ```